Hello,大家好,我是一名年資屆滿兩年的後端工程師,今年是首次參加 iThome 的鐵人賽,剛好最近開始為了年底的面試在做準備,希望藉由這個機會帶來更大的動力跟自制力,讓「我獨自升級」的速度大幅提升。身為一個從文組轉職的軟體工程師,一直以來崇尚實用主義,以解決當下碰到的技術問題為優先,常會面臨到「知其然,卻不知所以然」的狀況,這次通過幾個經典案例的 Lab 實作,去思考那些軟體開發背後的脈絡,進而將這些概念整理、內化到自身的知識庫。
而對於這個比賽來說,我期許自己「先完整,再完賽」,用心對待每一篇文章、每一次的實作、每個概念的理解,而不單單只是為了比賽而交作業,我相信當我敲下鍵盤的這一刻,我就已經贏過那個不敢行動的自己了,所以我不求榮譽、不求社群的認可、不用寫出多炫砲的技術文章,只想練好屬於我的一招一式。
接下來 30 天我會以 2 個系統設計的經典案例作為整體系列的骨幹,討論設計的思路、邏輯的推演,以及開發 Demo 時衍生的概念及知識,主要內容分為 3 大項:
而上述提到的經典案例分別為:
選擇這兩個系統作為主題實作,是因為它們跟我當前的日常工作密不可分,但大部分的模組都在我入職前都已經被前人開發完畢,也因此並不清楚細節,在這段時間內我將會用我的方式來實作這些系統。首先,在實作業務邏輯前,我們需要為後端服務器加上一層防護,而這個防護機制就是所謂的「網路限流器」。
從英文的字面上更好理解限流器的用途,所謂 Rate Limiter 就是用來控制、限制系統在任務處理的「頻率」,用來保護系統不被短時間大量的任務壓垮、防止資源被濫用和惡意攻擊,總之都是為了確保系統、服務的穩定性。而限流類型大致上可劃分為客戶端限流,以及服務端限流:
而限流器還有一個重點在於防止**失效擴散 (cascading failure),即一個服務因過載崩潰,可能連帶拖垮其他服務,**其餘用途包含但不限於以下原因:
主要限流算法分為以下幾類,而 Lab 1 將從最簡單的固定視窗計數器限流算法開始實作單機版本的服務,把所有算法的基礎功能都完成後,會朝向分布式架構邁進:
一言以蔽之,給每個「固定的時間窗口」分配一個計數器,每個請求訪問進來都會在計數器上加一,達到設定上限之後進來的請求就會被擋下。固定窗口計數器的算法一大優點就是實作起來非常簡單、效率高,但限流效果就相對不這麼可靠,因為在時間窗口的交界處容易產生流量突次問題,無法擋下預期外的流量,舉例而言:
接下來直接進入到實作環節,用的是 Spring Boot 的專案進行實作,並且採用 AOP (切面導向,Aspect Oriented)的模式限流,在這個 Lab 裡我們會用到的組件如下:
@Aspect
class :負責掃描、攔截有用到 @RateLimiter
限流器註解的方法,並執行限流的判斷邏輯。@RateLimiter
:可設置限流相關參數,並供系統在 Runtime 時可動態攔截、判斷該方法使用哪種限流算法、規則。@Aspect
類別AOP 概念圖:
以高層次理解 AOP 概念的話,就是在不同方法重複邏輯處一刀切,全部抽象到切面去,再用 Annotation 識別是否需要執行該邏輯,舉例來說 @Transactional
就是幫忙把事務管理的邏輯實作好了,只需要標記在方法上就會在執行的前後 (Around) 執行事務管理邏輯,不用手動實作,使得開發者專注在主要邏輯的實作就好。
下面是這次 Lab 1 的切面實作類,有幾個重點:
@Around("@annotation(rateLimiter)")
:切那些頭上有 @RateLimiter
的方法,切到後先執行限流器邏輯。
return joinPoint.proceed();
:執行完切面的限流算法後,把一刀切的面接回原呼叫的方法。
rateLimiterKeyGenerator.generateKey(joinPoint, rateLimiter);
:限流器需要一個 key 來區分各個獨立計數器,這邊 key 的範圍很大,是用每個 API 當作限制請求次數的來源,再縮小的話可能會加 ip 到 key 上,後面再說。
@Slf4j
@RequiredArgsConstructor
@Aspect
@Component
public class RateLimitAspect {
private final RateLimiterKeyGenerator rateLimiterKeyGenerator;
private final FixedWindowLimiter fixedWindowLimiter;
@Around("@annotation(rateLimiter)")
public Object around(ProceedingJoinPoint joinPoint, RateLimiter rateLimiter) throws Throwable {
var key = rateLimiterKeyGenerator.generateKey(joinPoint, rateLimiter);
var isAllowed = fixedWindowLimiter.isAllowed(key, rateLimiter.limit(), rateLimiter.window());
if (!isAllowed) {
var method = joinPoint.getSignature().toShortString();
log.warn("Rate limit exceed for method: {}, key: {}", method, key);
throw new BaseException(StatusCode.TOO_MANY_REQUEST,
String.format("Rate limit exceeded. Max %d requests per %d seconds",
rateLimiter.limit(), rateLimiter.window()));
}
log.debug("Rate limit passed for key: {}", key);
return joinPoint.proceed();
}
}
@RateLimiter
與上述切面類別對應的限流器註解,用來在方法上配置限流策略:
@Target(ElementType.METHOD)
:定義這個註解只能用在方法上。@Retention(RetentionPolicy.RUNTIME)
:註解資訊保留到 Runtime 期間,讓 AOP 可讀取並處理。@Documented
:註解會被包含在 JavaDoc 中。fallback()
和 fallbackClass()
:當限流觸發上限時的降級策略,可指定降級方法或降級類別。@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimiter {
Algorithm algorithm() default Algorithm.FIXED_WINDOW;
String key();
int limit() default 10;
int window() default 60;
String fallback() default "";
Class<?> fallbackClass() default Void.class;
enum Algorithm {
FIXED_WINDOW,
TOKEN_BUCKET,
SLIDING_WINDOW_LOG,
SLIDING_WINDOW_COUNTER,
LEAKY_BUCKET
}
}
接下來是計數器的實作,幾個重點:
ConcurrentHashMap
:為了妥善處理單體環境下數據儲存結構的併發任務,使用的是可支援多線程共享的 ConcurrentHashMap
,它透過把數據結構分段,讓每個線程可在同時間分別去訪問不同區段的資料,訪問期間利用分段鎖做細粒度的把控,讓每個資料佔用的範圍變得很小,也進而縮短了訪問時間,避免線程間等待時間過長,是個在高併發環境下適合使用的數據結構。Java 以後已經不用分段鎖了,改用 CAS 操作 (Compare-And-Swap),但是底層的核心概念都是「ConcurrentHashMap 透過細粒度的鎖控制,讓不同的資料區段可以被多個線程同時安全存取,減少線程間的等待時間。」
ScheduledExecutorService
生命週期:ScheduledExecutorService
是在類加載時,從 ThreadPool 中取得一個 Thread 來創建的,他的作用域是整個應用程序的生命週期 static
,一直存活在背景執行緒用於每 30 秒執行一次清理任務,完全獨立於當前執行緒。
private static final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
為何 Counter 要用 AtomicLong
:假設同一個 key 有多個請求進來,都要對同個計數器 +1,每一次 +1 的操作其實可以拆解成 3 個步驟:read → +1 → write
,那假設用普通的 long 累計的話,多線程可能讀取到相同的值,假設是 5,都對它 +1,然後都把 6 寫回計數器,這樣明明應該加 2 次,計數卻只增加 1 次。而 AtomicLong
保證計數為原子操作(基於 CAS),要嘛成功、要嘛失敗,確保操作完整性,它會把原先讀取的值連同增加的值一起提交,提交時發現當前的值已經不再是 5 了,便會 rollback 回去重新讀取成 6,然後更新為 7。
@Component
public class MemoryRateLimiterStorage implements RateLimiterStorage {
private static final ConcurrentHashMap<String, Counter> storage = new ConcurrentHashMap<>();
private static final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
public MemoryRateLimiterStorage() {
scheduler.scheduleAtFixedRate(this::cleanExpireKey, 30, 30, TimeUnit.SECONDS);
}
@Override
public long incrementAndSetExpire(String key, long expireSeconds) {
var expireTime = System.currentTimeMillis() + expireSeconds * 1000;
var counter = storage.compute(key, (k, existing) -> {
if (existing == null || existing.isExpired()) {
return new Counter(new AtomicLong(1), expireTime);
} else {
existing.count.incrementAndGet();
return existing;
}
});
return counter.count.longValue();
}
private void cleanExpireKey() {
storage.entrySet().removeIf(entry -> entry.getValue().isExpired());
}
private record Counter(AtomicLong count, long expireTime) {
public boolean isExpired() {
return System.currentTimeMillis() > expireTime;
}
}
}
固定窗口實作起來很單純,就是取得時間窗口內的當前計數值,用它去跟限流器的設定比大小即可。
@RequiredArgsConstructor
@Component
public class FixedWindowLimiter {
private final RateLimiterStorage storage;
public boolean isAllowed(String key, int limit, int windowSeconds) {
var currentCount = storage.incrementAndSetExpire(key, windowSeconds);
return currentCount <= limit;
}
}
最後來用個 Controller 來測試下效果。
@RequestMapping(value = "/rate")
@RestController
public class RateLimiterTestController {
@RateLimiter(key = "demo", limit = 5)
@GetMapping(value = "/window/fixed")
public ResponseEntity<String> getFixedWindowString() {
return ResponseEntity.ok("Rate limiter test");
}
}
實際發送 API,實際上我在 37 分時送了 3 個請求,但到了 38 分開始我又送了 5 個,直到第 6 個送出才報錯,所以總計 1 分鐘內我實際上送了 8 個請求,這就是固定窗口計數器算法不可靠的地方。
{
"error": "too_many_request",
"message": "Rate limit exceeded. Max 5 requests per 60 seconds"
}
雖然前面介紹固定窗口時,好像把它說得不太可靠,但事實上在真實使用場景中,固定窗口的使用率還是蠻高的,原因是它的優點也蠻明顯,那就是
很多場景下不需要過於複雜的限流算法,若不是特別需要應付突如其來的流量高峰,或對精確性有高要求的服務,那固定窗口其實就可以滿足大多數需求了。
明天的內容會以今天的成果為出發: